// $Id: rpsengine.jsm 40 2012-11-03 11:46:16Z hhofer69@gmail.com $

"use strict";

Components.utils.import("resource://gre/modules/Services.jsm");
Components.utils.import("chrome://reqpolsync/content/rpscachedb.jsm");
Components.utils.import("chrome://rpsmodules/content/common/syncutils.jsm");
Components.utils.import("chrome://rpsmodules/content/common/logutils.jsm");
Components.utils.import("resource://services-sync/engines.js");
Components.utils.import("resource://services-sync/record.js");
Components.utils.import("resource://services-sync/util.js");
Components.utils.import("resource://services-sync/main.js");
Components.utils.import("resource://services-sync/constants.js");

var EXPORTED_SYMBOLS = [ "RequestPolicySyncEngine", "ReqPolSyncRecord",
                         "ReqPolCache" ];

var trackerInstance = null;

let assert = SyncUtil.assert;

//*****************************************************************************
//* Helpers
//*****************************************************************************
function dbgFmt(obj) {
  if (!Logging.DEBUG) return "";
  let snip = function(key, value) { return (key == "code") ? value.substring(0, 256) : value; }
  return JSON.stringify(obj, snip);
}


//*****************************************************************************
//* Requestpolicy cache
//*****************************************************************************
                  
const POLICY_TYPES =  {
  orig: { pref:  "Origins",                  section: "origins",
          allow: "allowOrigin",              forbid:  "forbidOrigin" },
  dest: { pref:  "Destinations",             section: "destinations",
          allow: "allowDestination",         forbid:  "forbidDestination" },
  o2dt: { pref:  "OriginsToDestinations" ,   section: "origins-to-destinations",
                                             // REMOVEME: see comments in _reqpolCall
          allow: "allowOriginToDestination", forbid:"_forbidOriginToDestinationByCombinedIdentifier" }
};

function RequestyPolicyCache() {
}

RequestyPolicyCache.prototype = {
  PREF_BASE:      "extensions.requestpolicy.allowed",
  POLICY_TYPES:   POLICY_TYPES, // 'export'
  
  reqpol:  null,  // RequestPolicy service

  dbfile:  FileUtils.getFile("ProfD", ["reqpolsync", "reqpolcache.sqlite"]),
  db:      null,

  _cache:  null,
  _track:  true,
  _guids:  null, // TODO: cache guids

  restoreThreshold: 64, // importing more than this will trigger a sync prompt
  
  get dbdir() this.dbfile.parent,
  get ok()    (this.db && this._cache),

  init: function RPC_init() {
    if (this._cache) return;
    this.reqpol   = Components.classes["@requestpolicy.com/requestpolicy-service;1"].getService(Components.interfaces.nsIRequestPolicy);
    this.jsreqpol = this.reqpol.wrappedJSObject; // REMOVEME: see comments in wipeRequestPolicy
    if (!this.reqpol) throw new SyncError("RequestPolicy not found");
    if (!this.dbdir.exists()) this.dbdir.create(1, 0x700);
      
    this.initDatabase();
    
    try {
      this.startBatch(); // might generate a lot of records...
      this.refresh();
    } finally {
      this.commitBatch(true);
    }

    Services.prefs.addObserver(this.PREF_BASE, this, false);
    
  },
  
  close: function RPC_close() {
    Services.prefs.removeObserver(this.PREF_BASE, this);
    this.db.close()
    delete this._cache;
  },
  
  load: function RPC_load() {
    let self  = this;
    let types = this.POLICY_TYPES
    let ret   = {}
    for (let t in types) {
      let pref = Services.prefs.getCharPref(this.PREF_BASE + types[t].pref);
      if (!pref) continue;
      pref.split(/\s+/).forEach(function RPC_addCache(value) {
        let item = { type: t, value: value }
        // as a _important_ side effect this fills the DB, too
        ret[self.makeItemGUID(item)] = item;
      });
    }
    return ret;
  },
  
  get: function RPC_get(guid) {
    if (this._cache) return this._cache[guid];
    return null;
  },
  
  add: function RPC_add(item, guid) {
    if (this._cache == null) this._cache = {};
    if (guid) this.db.setGUID(item, guid);
    // if no guid is given, create one (in the DB)
    this._cache[guid || this.makeItemGUID(item)] = item;
  },
  
  remove: function RPC_remove(item) {
    if (this._cache == null) this._cache = {};
    let guid = this.db.getGUID(item);
    if (guid) {
      delete this._cache[guid];
      this.db.delGUID(item);
    } else {
      Logging.warn("Trying to remove uncached item "+dbgFmt(item));
    }
  },
  
  items: function RPC_items() {
    if (!this._cache) return {};
    return this._cache;
  },
  
  _reqpolCall: function RPC__reqpolCall(item, func, delay) {
    let service = this.reqpol;
    let fname   = this.POLICY_TYPES[item.type][func]+(delay ? "DelayStore" : "");
    let args    = item.value.split("|");
    
    // HACK ALERT: There seems to be a bug RequestPolicy:
    // forbidOriginsToDestinations ignores its noStore flag! 
    // So we'll use the undocumented internal functions for now!
    if (/^_(allow|forbid)OriginToDestinationByCombinedIdentifier.*/.test(fname)) {
      service = this.jsreqpol;
      fname   = this.POLICY_TYPES[item.type][func];
      args    = [ item.value, delay ];
    } // HACK ALERT END
    
    // Logging.debug("reqpol: "+fname+"("+dbgFmt(args)+")");
    service[fname].apply(service, args);
    if (!delay)
      SyncUtil.yield();
  },
  
  allow:  function RPC_allow(item, guid) {
    this.add(item, guid);
    this._reqpolCall(item, "allow", !this._track);
  },

  forbid: function RPC_forbid(item) {
    this.remove(item);
    this._reqpolCall(item, "forbid", !this._track);
  },
  
  existing: function RPC_exists(item) {
    return this.db.getGUID(item);
  },
  
  change: function RPC_change(oguid, nguid) {
    let item = this.get(oguid);
    if (!item) { Logging.warn("cache: trying to change non-existing item '"+oguid+"'"); return; }
    this.remove(item);
    this.add(item, nguid);
  },
  
  startBatch:  function RPC_startBatch() {
    if (!this._track) { Logging.warn("cache batch already active"); return; }
    this._track = false;
    this.db.begin();
  },
  
  commitBatch: function RPC_commitBatch(dbonly) {
    if (!dbonly) {
      this.reqpol.storeAllPreferenceLists();
      this._cache = this.load();
    }
    if (!this._track)
      this.db.commit();
    this._track = true;
    SyncUtil.yield();
  },

  wipeRequestPolicies: function RPC_wipeRequestPolicies(storeObj) {
    let toClear = this.load();
    try {
      this.startBatch();
      for each (let item in toClear)
        this.forbid(item, true);

      if (Object.keys(this._cache).length != 0) {
        Logging.warn("Cache mismatch: not empty after wipe");
        Logging.debug("cache: "+dbgFmt(this.cache));
      }
      SyncUtil.yield();
      this.db.wipe();
      SyncUtil.yield();
    } finally {
      this.commitBatch();
    }
  },
  
  makeItemGUID: function RPC_makeItemGUID(item) {
    // get or create (in the DB) a GUID for an item hash
    let guid = this.db.getGUID(item)
    if (!guid) {
      guid = Utils.makeGUID();
      this.db.setGUID(item, guid);
    }
    return guid;
  },
  
  refresh: function RPC_refresh() {
    let start = Date.now();
    if (this._cache == null) this._cache = {};
    let upd  = { added: 0, deleted: 0};
    let neew = this.load();
    for (let guid in neew)        if (!(guid in this._cache))
      { /* alread in cache and db */     upd[guid] = true; upd.added++; }
    for (let guid in this._cache) if (!(guid in neew)) 
      { this.db.delGUID(this.get(guid)); upd[guid] = true; upd.deleted++; }
    delete this._cache;
    this._cache = neew;
    Logging.debug("cache refreshed in "+(Date.now()-start)+"ms");
    return upd;
  },
  
  observe: function RPC_observe(subject, topic, data) {
    try {
      if (topic == "nsPref:changed") {
        if (!this._track) return;
        if (!/^extensions\.requestpolicy\.allowed.*$/.test(data)) return;
        let upd = this.refresh();

        let { added, deleted } = upd;
        delete upd.added; delete upd.deleted;

        if (added + deleted > 0) {
          Logging.debug((added+deleted)+" updated preference items");

          let data = JSON.stringify(upd);
          Services.obs.notifyObservers(null, "reqpolsync-mod", data);

          if (added > this.restoreThreshold) {
            Services.obs.notifyObservers(null, "reqpolsync-restored", data);
            Logging.debug("triggered restore prompt");
          }
        }
      }
    } catch (exc) {
      Logging.logException(exc);
      throw(exc);
    }
  },
  
  initDatabase: function RPC_initDatabase() {
    ReqPolSyncDB.init(this.dbfile)
    this.db = ReqPolSyncDB;
  }

};

var ReqPolCache = new RequestyPolicyCache();
let Cache       = ReqPolCache;

//*****************************************************************************
//* Custom Sync Engine classes
//*****************************************************************************

const REQPOL_PROPS = [ "type", "value" ];

function ReqPolSyncRecord(collection, id) {
  CryptoWrapper.call(this, collection, id);
}

ReqPolSyncRecord.prototype = {
  __proto__: CryptoWrapper.prototype,
  _logName: "Record.ReqPolSync",
  
  // FIXME: see syncutils.jsm
  decrypt: function FIX_decrypt() SyncUtil.fixDecryptBug.apply(this, arguments),
};

Utils.deferGetSet(ReqPolSyncRecord, "cleartext", REQPOL_PROPS);

function ReqPolSyncStore(name, engine) {
  Store.call(this, name, engine);
}

ReqPolSyncStore.prototype = {
  __proto__: Store.prototype,
  
  itemExists: function STS_itemExists(id) {
    let ok = !!Cache.get(id);
    Logging.debug("itemExists: '"+id+"' "+ok);
    return ok;
  },
  
  applyIncomingBatch: function RPS_applyIncomingBatch(records) {
    Logging.debug("applyIncomingBatch: size = "+records.length);
    try {
      Cache.startBatch();
      return Store.prototype.applyIncomingBatch.call(this, records);
    } finally {
      Cache.commitBatch();
    }
  },
  
  createRecord: function RPS_createRecord(id, coll) {
    let rec  = new ReqPolSyncRecord(coll, id);
    let pol  = Cache.get(id);
    if (!pol) { rec.deleted = true; } // I've got this from engines/passwords.js:
    else      { rec.type = pol.type; rec.value = pol.value; }
    Logging.debug("createRecord: "+id+", "+coll+", "+dbgFmt(rec));
    return rec;
  },
  
  changeItemID: function STS_changeItemID(old, neew) {
    Logging.debug("changeItemID: "+old+" -> "+neew);
    Cache.change(old, neew);
  },
  
  getAllIDs: function STS_getAllIDs() {
    Logging.debug("getAllIDs");
    let guids  = {};
    for (let guid in Cache.items()) guids[guid] = true;
    return guids;
  },
  
  wipe: function STS_wipe() {
    Logging.debug("wipe");
    Cache.wipeRequestPolicies(this);
  },
  
  create: function STS_create(rec) {
    Logging.debug("create: "+dbgFmt(rec.cleartext));
    return Cache.allow(rec, rec.id);
  },
  
  update: function STS_update(rec) {
    // Gecko <= 10.x doesn't handle dupes properly...
    Logging.warn("update on existing record - ignored: "+dbgFmt(rec.cleartext));
    // activate when Gecko 11 is out: throw new SyncError("Only create and remove allowed");
  },
  
  remove: function STS_remove(rec) {
    Logging.debug("remove: "+rec.id);
    let pol = Cache.get(rec.id);
    if (pol) Cache.forbid(pol);
  },
};

ReqPolSyncStore = SyncUtil.errorLoggedClass(ReqPolSyncStore);

function ReqPolSyncTracker(name, engine) {
  Tracker.call(this, name, engine);
  // Because we're in a bootstrapped addon, we won't get
  // weave:engine:start-tracking at first, but maybe on a sync start-over
  // so start tracking immediately and 
  this.startTracking();
}

ReqPolSyncTracker.prototype = {
  __proto__: Tracker.prototype,
  _enabled:  true,
  
  observe: function RPT_observe(subject, topic, data) {
    Logging.debug("STT_observe: " + dbgFmt(subject) + ", " + topic+ ", " + data);
    switch (topic) {
      case "reqpolsync-mod":
        let upd = JSON.parse(data);
        for (let guid in upd)
          this.addChangedID(guid);
        let now = Services.prefs.getBoolPref("extensions.reqpolsync.immediate");
        this.score += (now ? SCORE_INCREMENT_XLARGE :
                             SCORE_INCREMENT_MEDIUM * Object.keys(upd).length);
        break;
        
      case "weave:engine:stop-tracking":
        this.stopTracking();
        break;
        
      case "weave:engine:start-tracking":
        Services.obs.removeObserver(this, "weave:engine:start-tracking");
        this.startTracking();
        break;
    }
  },
  
  
  startTracking: function RPT_startTracking() {
    if (!trackerInstance) {
      Services.obs.addObserver(this, "reqpolsync-mod",    false);
      Logging.debug("ReqPolSyncTracker singleton created");
      trackerInstance = this;
    } else {
      Logging.debug("ReqPolSyncTracker already present!");
    }
  },    
  
  stopTracking: function STT_stopTracking(shutdown) {
    if (trackerInstance) {
      Services.obs.removeObserver(this, "reqpolsync-mod");
      trackerInstance = null;
      Logging.debug("ReqPolSyncTracker singleton deleted");
      // now, we can start listening again
      if (!shutdown)
        Services.obs.addObserver(this, "weave:engine:start-tracking", false);
    }
  }
  
};

ReqPolSyncTracker = SyncUtil.errorLoggedClass(ReqPolSyncTracker);

function RequestPolicySyncEngine(service) {
  try {
    SyncEngine.call(this, "ReqPolSync", service);
    this.strings = new SyncStringBundle(this.name);
    ReqPolCache.init();
    assert(ReqPolCache.ok, "Cache failed to initialize");
  } catch (exc) {
    Logging.logException(exc);
    throw(exc);
  }
}

RequestPolicySyncEngine.prototype = {
  version:     1,
  __proto__:   SyncEngine.prototype,
  _recordObj:  ReqPolSyncRecord,
  _storeObj:   ReqPolSyncStore,
  _trackerObj: ReqPolSyncTracker,
  applyIncomingBatchSize: 500,

  _findDupe: function STE_findDupe(rec) {
    return ReqPolCache.existing(rec.cleartext);
  },

  shutdown: function RPE_shutdown() {
    if (this.trackerInstance) this.trackerInstance.stopTracking(true);
    ReqPolCache.close();
  },

  get trackerInstance() {
    return trackerInstance;
  }
};

// FIXME: RequestPolicySyncEngine = StsUtil.errorLoggedClass(RequestPolicySyncEngine);
